SDKS-2810: Add Invisible reCAPTCHA, hCaptcha, and Enterprise#478
Conversation
🦋 Changeset detectedLatest commit: 3d52dfc The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
I see that Gabriel has added callbacks and stages using the old legacy forgerock sdk, so this will stop working completely as soon as my journey client changes are merged to main. I suggest we use the new journey client here, because my journey client PR #468 will soon be merged to main.
vatsalparikh
left a comment
There was a problem hiding this comment.
Initial review comments
| const recaptchaClass: string = | ||
| callback?.getOutputByName<string>('captchaDivClass', 'h-captcha') ?? 'h-captcha'; | ||
|
|
||
| const captchaProvider: 'hcaptcha' | 'grecaptcha' = |
There was a problem hiding this comment.
We are handling both hCaptcha and recaptcha in this file and in the folder in general, so we should rename all files under and along with recaptcha folder to be captcha instead
There was a problem hiding this comment.
Fair point. I think we should add this to a new tech debt ticket, along with a few other comments from this PR, to avoid adding more changes into an already large diff.
cerebrl
left a comment
There was a problem hiding this comment.
I'd really like for us to reconsider some architectural choices here. If you want, we can jump on a call tomorrow and discuss it.
| }) { | ||
| if (nameOfCaptcha === 'hcaptcha' && window.hcaptcha) { | ||
| return window.hcaptcha.render('fr-recaptcha', { | ||
| return window.hcaptcha.render(elementId, { |
There was a problem hiding this comment.
This deviates from our conventions of utility functions being pure, stateless functions. Typically, anything that involves DOM or Window use is in a Svelte component. But, if something needs to access external systems, we would call it an "effect" (not to be confused with Effect, the library).
With that said, I see that this file already has window use, so we can leave it. But, I do want to stress that we should move away from this as it should be considered an anti-pattern.
There was a problem hiding this comment.
Agreed. We can tackle this alongside the stage import in recaptcha.svelte callback file in a new ticket.
There was a problem hiding this comment.
Since I was already doing some refactoring, I went ahead and replaced the global window assignments with closures passed directly to renderCaptcha / renderCaptchaInvisible / renderEnterpriseCaptcha. Both reCaptcha and hCaptcha support a programmatic render() API that accepts function refs, so no window lookup needed, no shared-global risk, no core/window.d.ts type.
63bd15f to
c8ec83e
Compare
There was a problem hiding this comment.
Overall, looking good now, the codebase looks good.
Justin already mentioned comments about using callback metadata (or similar) instead of captcha store and putting window in a separate file so those utility files should become leaner, and any effectful code should be under effects file for both enterprise and normal recaptcha.
I tested recaptcha enterprise with login journey. Gabriel helped configuring a new recatpcha enterprise, and I was able to solve the recaptcha but login failed. It's possible that the new config was missing something.
a26e3f9 to
daa76ed
Compare
ryanbas21
left a comment
There was a problem hiding this comment.
First pass, this will take me a few reads because its ReCaptcha.
| For CAPTCHA-enabled journeys: use enterprise.js, not api.js — | ||
| new Google keys require the Enterprise namespace. | ||
| --> | ||
| <script src="https://www.google.com/recaptcha/enterprise.js" async defer></script> |
There was a problem hiding this comment.
Don't we need the siteKey in here? or is this changed now?
There was a problem hiding this comment.
No, for Enterprise the key is passed to render(element, { sitekey }) or execute(siteKey) at render time, the script just needs to be loaded. The siteKey value comes directly from callback?.getSiteKey().
There was a problem hiding this comment.
Update: siteKey isn't needed in the API call for reCaptcha v2 only. Enterprise and v3 does need it though. Fixing now.
There was a problem hiding this comment.
This should be fixed.
| PingOneProtectInitializeCallback, | ||
| PollingWaitCallback, | ||
| ReCaptchaCallback, | ||
| ReCaptchaEnterpriseCallback, |
There was a problem hiding this comment.
So we're now creating a distinct ReCaptchaEnterpriseCallback.
Because now in the new "Recaptcha world" there is no significant difference between this for google, this feels like we're diverting our structure away from the actual core Domain (ReCaptcha)
I think maybe what we need to do is align more against HCaptcha vs Google ReCaptcha.
Since GoogleReCaptcha has Enterprise, and others, but they are all under the domain of Enterprise Recaptcha, it can be a bit more resilient to future changes from Google. If they rename, add something, we have just Google based ReCaptcha component that handles that logic, we operate there.
HCaptcha being distinct creates the same separation.
My Concern is that we are creating the separation "Enterprise" vs not, and then if HCaptcha has or adds an Enterprise, are we going to put all Enterprise based logic in the recaptcha-enterprise component?
It feels like the abstraction may be on the wrong thing and should be on the Google vs HCaptcha.
This may help clean up some of the mess I originally created when writing this because now the logic for each is coupled (correctly) to the component that it is.
The issue this does create is in this file, since we check the type, we'd need one more layer of logic which says "Which type" of Recaptcha is this.
I think in this case, this second "check" is worth doing for the separation we can create.
Thoughts?
There was a problem hiding this comment.
Fair point. The component split follows AM's callback structure, not our design. AM sends ReCaptchaEnterpriseCallback (different from ReCaptchaCallback) for the Enterprise captcha node, and it has a different API too. One component for both would need duck-typing or internal branching, which can be harder to read and test. hCaptcha only uses ReCaptchaCallback, no Enterprise variant, so it stays in the classic component. If AM adds an hCaptcha Enterprise callback later, it could get its own component or reuse the enterprise one.
If AM is our source of truth for callback components, which it mostly is (one exception aside), a dedicated ReCaptchaEnterpriseCallback makes more sense. That said, I get your point about the abstraction belonging at the ReCaptcha vs. hCaptcha level. It really comes down to following AM's structure or creating our own for captcha callbacks.
There was a problem hiding this comment.
I hear this, but i'm not sure that following AM nodes here is the right decision since the real implementation is more specific to the provider. I'm not hard set on this but just my thoughts
There was a problem hiding this comment.
I see your point. The existing reCaptchaCallback naming can be specially misleading/confusing since it's used by both reCaptcha and hCaptcha, even though that's the callback name returned by the Captcha node. reCaptchaEnterprise is more consistent with the journey node and its intended use.
I'll spend some time splitting the callback components into hcaptcha.svelte and recaptcha.svelte (classic and enterprise) to see if organizing them by provider makes more sense. Thanks!
| const journeyParam = $page.url.searchParams.get('journey'); | ||
| const suspendedIdParam = $page.url.searchParams.get('suspendedId'); | ||
| const captchaModeParam = $page.url.searchParams.get('captchaMode') as | ||
| | 'normal' |
There was a problem hiding this comment.
I think maybe we should use the actual names of recaptcha here, normal is understandable but I think it may be more clear to use invisible and Not a Robot then we have a third type which is Score based
If we use normal and the world changes to Score based we are out of sync with the standard and we break the api.
There was a problem hiding this comment.
Good point. We could rename normal to checkbox or visible if that's clearer. No breaking behavior change, just a string value in the public interface.
There was a problem hiding this comment.
Renamed 'normal' to 'visible'. Chose 'visible' over 'checkbox' as it's provider-agnostic and describes the behavior clearly for both Google and hCaptcha. Let me know what you think.
There was a problem hiding this comment.
Why not just use the names of the recaptcha instead? "Not a robot" | "invisible" | "score" ?
There was a problem hiding this comment.
Because the captcha mode option controls both google reCaptcha and hCaptcha. "Not a robot" is google's checkbox UI copy, not a mode name, and hCaptcha has no equivalent term. 'score' isn't a valid value here either, v3/score-based mode is detected directly from the AM payload (reCaptchaV3: true output) and never needs to be set by the consumer. So the only real choice a consumer makes is how the widget renders: does it show a visible checkbox or execute silently in the background? 'visible' | 'invisible' describes that behavior precisely and stays correct and consistent regardless of the provider.j
cerebrl
left a comment
There was a problem hiding this comment.
I have a few comments around the continued use of the captcha store and if it's still needed. The other captcha comments I'll leave to Ryan as I'm not familiar enough with the spec to weigh in.
|
|
||
| export const recaptchaActionStore = derived(journeyStore, ($store) => ({ | ||
| recaptchaAction: $store.recaptchaAction ?? '', | ||
| })); |
There was a problem hiding this comment.
Is this still needed now that we have the value woven into the callbackMetadata?
There was a problem hiding this comment.
Updated. Passed recaptchaAction through callbackMetadata using the same initOptions pattern as captcha.mode. buildCallbackMetadata now merges recaptchaAction from initializationOptions into initOptions for both captcha callback types. Both components now read callbackMetadata?.initOptions?.recaptchaAction reactively. No store subscription and recaptchaActionStore` removed. Thanks!
56be677 to
e870cef
Compare
ryanbas21
left a comment
There was a problem hiding this comment.
Just a few comments otherwise looks good
| const hcaptcha = () => (window as Window & { hcaptcha?: HCaptcha }).hcaptcha; | ||
|
|
||
| export function detectEnterpriseProvider(elementClass: string): 'grecaptcha' | 'hcaptcha' { | ||
| return elementClass === 'g-recaptcha' ? 'grecaptcha' : 'hcaptcha'; |
There was a problem hiding this comment.
can't we change this class at the node level?
There was a problem hiding this comment.
This function was detecting the captcha provider based on the class name coming from the captcha callback, but ReCaptchaEnterpriseCallback is Google-only so the hCaptcha branch in the enterprise component was dead code.
Removed detectEnterpriseProvider, the HCaptcha interface, and the hcaptcha render branch. Enterprise always uses grecaptcha.
|
|
||
| onMount(() => { | ||
| const captchaProvider: 'hcaptcha' | 'grecaptcha' = | ||
| recaptchaClass === 'g-recaptcha' ? 'grecaptcha' : 'hcaptcha'; |
There was a problem hiding this comment.
again i'm concerned that we're relying on this but it's a node level setting if I recall
There was a problem hiding this comment.
captchaDivClass is read directly from the callback output (getOutputByName('captchaDivClass')), so the AM node does control it. We need it not just for the css class but to route between hCaptcha and Google. it drives script loading, and the hcaptcha.render() vs grecaptcha.render() branching. It's the only signal the callback exposes that we can use to determine the provider.
| recaptchaClass === 'g-recaptcha' ? 'grecaptcha' : 'hcaptcha'; | ||
|
|
||
| // v3 is always Google; v2 provider is derived from captchaDivClass | ||
| const scriptSrc = isV3 |
There was a problem hiding this comment.
np: maybe an object that maps the urls to the type?
There was a problem hiding this comment.
Yeah, good call! Replaced the ternary chain with a CAPTCHA_SCRIPT_URLS map key. Thanks!
c9f33f2 to
6199372
Compare
cerebrl
left a comment
There was a problem hiding this comment.
I like the state of this PR. The only changes are the "effectful" utilities. Let's just reorganize/rename these files to reflect the fact that they are not pure, stateless functions.
There was a problem hiding this comment.
Since this is a new file, I'd like to move towards the pattern of utilities being pure, stateless functions. Since these manage "effects", like reading from the window object or interacting with the DOM, let's just reorganize/rename this as an effect.
There was a problem hiding this comment.
Updated. Split all three files following the WebAuthn pattern. Pure functions remain in *.utilities.ts; DOM/window effects moved to *.effects.ts. Thanks!
There was a problem hiding this comment.
Provided code suggestions in respective lines. Below are some PR-wide suggestions
- File structure of utilities and effects files is confusing. We should consolidate this logic under callbacks/ folder because this is callback related logic
- We have effects and utilities under recaptcha-enterprise. We don't have this pattern, those should be moved out to dedicated _effects and _utilities folders.
- Then we have effects and utilities under stages for recaptcha
- And we have effects and utilities under journey/_utilities.
- Any effects files should be under its own _effects folder and _utilities under its own _utilities folder.
- For 1 pending check, please approve or deny the changes in chromatic before merging. I've found UI regressions from my PRs here before.
|
|
||
| const mountMode = | ||
| ((callbackMetadata?.initOptions?.mode as string | undefined) ?? 'visible') === 'invisible' | ||
| ? 'invisible' |
There was a problem hiding this comment.
I think we should define a type for captcha mode, maybe CaptchaMode. It seems like this value is referenced across lots of files. Defining it as a type will avoid typos and as casting, and provide a single reference across multiple files.
There was a problem hiding this comment.
Good call. I added CaptchaMode = 'visible' | 'invisible' type in journey.interfaces.ts. Both callback components now import and use it. This also fixed a TS error where initOptions caused the reactive captchaMode to widen to string, which broke the loadEnterpriseScript mode param. Thanks!
| // AM node may not expose the action input — non-fatal, token already set | ||
| } | ||
| } catch (err) { | ||
| console.error('[recaptcha-enterprise] execute() threw:', err); |
There was a problem hiding this comment.
do we want to log out consoles in production code?
There was a problem hiding this comment.
No, that was a debug console.error. Removed, thanks.
| mode: mountMode, | ||
| }); | ||
| } catch (err) { | ||
| console.error('[recaptcha-enterprise] script load failed:', err); |
There was a problem hiding this comment.
console in prod code again
There was a problem hiding this comment.
Removed. Thank you.
7926ae1 to
a6cc346
Compare
vatsalparikh
left a comment
There was a problem hiding this comment.
Current review non-blocking comments
- We should document the recaptcha config gotchas because they deviate from official recaptcha enterprise docs. This should be documented in this repo's README file so the knowledge doesn't stay tribal.
- Some new line-specific non-blocking comments
Previous review non-blocking comments
- File structure of utilities and effects files is confusing. We should consolidate this logic under callbacks/ folder because this is callback related logic
We have effects and utilities under recaptcha-enterprise. We don't have this pattern, those should be moved out to dedicated _effects and _utilities folders.
Then we have effects and utilities under stages for recaptcha
And we have effects and utilities under journey/_utilities.
Any effects files should be under its own _effects folder and _utilities under its own _utilities folder. - For 1 pending check, please approve or deny the changes in chromatic before merging. I've found UI regressions from my PRs here before.
5836e96 to
3cfa823
Compare
…erprise support fix(captcha): replace javascript-sdk imports with journey-client in story/mock files and restore vitest coverage-v8 dep refactor(captcha): replace captcha.store with initOptions in buildCallbackMetadata refactor(captcha): thread recaptchaAction through callbackMetadata, remove recaptchaActionStore refactor(captcha): unify script injection via shared captcha.utilities refactor(captcha): separate effectful functions into *.effects.ts files fix(captcha): guard executeEnterpriseCaptcha against missing grecaptcha and prevent double-render fix(captcha): add Zod validation schema for captcha config option docs(captcha): correct README — widget injects scripts automatically fix(captcha): fix visible onError infinite loop, unsafe captchaMode cast, and console.error noise fix(captcha): fix layer violation, TDZ, v3 error handling, and add initOptions tests fix(captcha): remove redundant mountMode local; use reactive captchaMode in onMount fix(captcha): fix existing-script race, empty apiUrl fallback, and v3 null deref fix(captcha): fix Tier 2 findings — elementId in invisible render, enterprise onError, v3 error UI refactor(captcha): remove dead re-export, drop redundant captchaMode type annotations refactor(captcha): consolidate effects/utilities under callbacks/_effects and callbacks/_utilities
Summary
https://pingidentity.atlassian.net/browse/SDKS-2810
Adds invisible reCAPTCHA v2 and invisible hCaptcha support to the login widget via a new
configuration({ captcha: { mode: 'invisible' } })option. Also adds a dedicatedReCaptchaEnterpriseCallbackhandler for AM journeys using the Enterprise CAPTCHA node. The widget now auto-injects CAPTCHA scripts — no manual<script>tag required from consumers for any variant.Changes
core/captcha.config.tscaptchaConfigSchema) for thecaptchaconfig option — validatesmode: 'visible' | 'invisible'. Strict so unknown keys throw at parse time.core/journey/_utilities/captcha.effects.tsresolveGrecaptcha()— preferswindow.grecaptcha.enterprise, falls back towindow.grecaptcha. Transparent to consumers regardless of which script they loaded (classicapi.jsorenterprise.js).loadCaptchaScript({ src, provider })— injects a<script>tag and resolves when the provider API is ready. No-ops if the provider is already onwindow(pre-loaded or test stub). Race-safe:settledflag,errorlistener, TOCTOU double-check.renderCaptcha(...)— visible v2 render for both Google and hCaptcha.renderCaptchaInvisible(...)— invisible v2 render + execute for both providers.core/journey/stages/_effects/captcha.effects.tsrenderCaptcha,renderCaptchaInvisible,resolveGrecaptchafrom_utilities/captcha.effects.ts. Preserves the layer boundary sostages/consumers keep their existing import path.core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte— handlesReCaptchaEnterpriseCallback. Visible checkbox or score-based invisible flow driven bycaptchaMode. CallssetAction(),setResult(), andsetClientError()on execute. Inline<Alert type="error">on failure.recaptcha-enterprise.effects.ts—renderEnterpriseCaptcha,executeEnterpriseCaptcha,loadEnterpriseScript.loadEnterpriseScriptappends?render=SITE_KEYfor invisible/score-based keys (required forexecute()).recaptcha-enterprise.utilities.ts—buildEnterpriseScriptSrcpure helper.core/journey/callbacks/recaptcha/recaptcha.svelteloadCaptchaScript, then callsrenderCaptchaInvisibleon mount for both Google and hCaptcha.captchaModedriven bycallbackMetadata.initOptions.mode(threaded viabuildCallbackMetadata— no store).CaptchaModetyped declaration prevents reactive widening tostring.onMountawaitsloadCaptchaScriptbefore render; v3 path also self-injectsapi.js.core/journey/_utilities/metadata.utilities.tsbuildCallbackMetadataaccepts a newinitializationOptionsparam. ForReCaptchaCallbackandReCaptchaEnterpriseCallback, mergescaptchaandrecaptchaActionfrominitializationOptionsintoinitOptionson the returned metadata object. Both callback components read mode and action fromcallbackMetadata.initOptions— no store subscriptions.core/journey/_utilities/callback-mapper.svelteCallbackType.ReCaptchaEnterpriseCallback→recaptcha-enterprise.svelte.core/journey/journey.interfaces.tsCaptchaMode = 'visible' | 'invisible'exported type — shared across both callback components.packages/login-widget/src/lib/interfaces.ts+api.utilities.tscaptcha?: { mode?: CaptchaMode }option onWidgetConfigOptions. Validated viacaptchaConfigSchema.parse()at bothinitializeandsetcall sites.packages/login-widget/README.md'visible' | 'invisible'mode values, Enterprise backward-compatibility note.Architecture notes
captchaModethreading (no store)captcha.store.tswas removed during review.captchaModeflows throughbuildCallbackMetadata'sinitializationOptionsparam →CallbackMetadata.initOptions.mode→ component prop. This keeps the value co-located with the callback that uses it, consistent with the WebAuthn autofill pattern.Script injection
The widget now owns CAPTCHA script injection for all variants.
loadCaptchaScriptearly-exits if the provider API is already onwindow, so pre-loading in the host page still works. Enterprise invisible keys require?render=SITE_KEYin the script URL forexecute()—buildEnterpriseScriptSrchandles this automatically.Layer boundary
Callbacks import shared effects from
core/journey/_utilities/captcha.effects.ts.stages/code re-exports from there viastages/_effects/captcha.effects.ts. Callbacks never import fromstages/.Tests
captcha.effects.test.ts— 7 tests (resolveGrecaptcha,loadCaptchaScript: new inject, reuse existing, grecaptcha.ready, onerror reject)stages/_effects/captcha.effects.test.ts— 12 tests (renderCaptcha,renderCaptchaInvisible, Enterprise namespace, classic fallback)recaptcha-enterprise.effects.test.ts— 9 tests (renderEnterpriseCaptcha,executeEnterpriseCaptcha, null-grc guard)recaptcha-enterprise.utilities.test.ts— 2 tests (buildEnterpriseScriptSrc)recaptcha.utilities.test.ts— 2 tests (checkForHCaptcha)metadata.utilities.test.ts— 10 tests (6 newinitializationOptionscoverage)VisibleGoogle,VisibleHCaptcha,InvisibleGoogle,InvisibleError+VisibleEnterprise,InvisibleEnterprise,InvisibleEnterpriseErrorroute.fulfillAM mocking — no live AM dependencyHow to test
1. Unit tests
All 156 tests should pass.
2. Storybook
Navigate to Callbacks/ReCaptcha and Callbacks/ReCaptchaEnterprise — verify all story variants render without errors.
3. E2E tests
pnpm build:app pnpm --filter @forgerock/login-widget-e2e exec playwright install chromium pnpm ci:e2e -- tests/widget/modal/widget-modal.recaptcha.test.js pnpm ci:e2e -- tests/widget/inline/widget-inline.recaptcha.test.js4. Manual AM testing
Setting up a CAPTCHA node in an AM journey
Classic reCAPTCHA v2 (visible or invisible):
Authentication Nodes → CAPTCHA).localhost).captchaDivClass: h-captchaautomatically for hCaptcha keys.Startand your success/failure outcomes.Invisible mode: No AM config change needed. Pass
configuration({ captcha: { mode: 'invisible' } })in the widget consumer.reCAPTCHA Enterprise:
Authentication Nodes → reCAPTCHA Enterprise).ReCaptchaEnterpriseCallback— the widget handles this automatically via the new component.configuration({ captcha: { mode: 'invisible' } }).Verifying the integration
Visible mode (v2 checkbox):
Invisible mode (v2 invisible / hCaptcha invisible):
Enterprise invisible (score-based v3-style):
execute()fires on mount with the configuredrecaptchaAction(e.g.'LOGIN').Verifying token submission via browser DevTools:
authenticatePOST request.callbacksarray. TheReCaptchaCallbackorReCaptchaEnterpriseCallbackinput should have a non-emptyvalueforIDToken<n>token.IDToken<n>actionis set to your configured action string.Verifying Enterprise backward compatibility:
enterprise.js— confirmresolveGrecaptcha()resolves towindow.grecaptcha.enterprise.api.jsinstead — confirm it falls back towindow.grecaptchawithout errors.